할인조건에 대한 고민- 횟수 할인, 시간 할인, 재결제 할인, 쿠폰 할인, 비율할인
Q.
객체지향 관점에서 할인조건에 대한 고민: 횟수 할인, 시간 할인, 재결제 할인, 쿠폰 할인, 비율할인을 공통인터페이스 "타임슬롯 개별 가격을 계산하라"에 적합하게 선택되어 계산해주는 플랜을 세워보자. 타임슬롯과 Day Availability, AvailableTimeResponseDto를 제공해줄게.
A. Claude Sonet 4
제공해주신 코드를 분석해보니, 타임슬롯 기반의 예약 시스템에서 다양한 할인 조건을 객체지향적으로 설계하는 흥미로운 문제네요. 할인 전략 패턴을 활용한 설계안을 제안해드리겠습니다.
이 설계의 핵심 특징들을 설명드리겠습니다:
🎯 전략 패턴 기반 설계
1. 공통 인터페이스 (IDiscountStrategy
)
- 모든 할인 전략이 구현해야 하는 표준 인터페이스
canApply()
: 할인 적용 가능 여부 판단calculateDiscountedPrice()
: 실제 할인 가격 계산getDiscountInfo()
: UI 표시용 할인 정보 제공
2. 구체적인 할인 전략들
- 횟수 할인: 누적 예약 횟수 기반 (10회/20회/50회 티어)
- 시간 할인: 아침/오후/늦은시간대 할인
- 재결제 할인: 연속 예약일 기반
- 쿠폰 할인: 비율/고정금액/시간기반 쿠폰
- 장시간 할인: 1회 예약 시 긴 시간 예약시 할인
🏗️ 확장성 고려사항
새로운 할인 전략 추가가 용이:
// 새로운 할인 전략 추가 예시
export class WeatherDiscountStrategy implements IDiscountStrategy {
readonly strategyId = 'weather_discount';
readonly priority = 6;
canApply(context: IPricingContext): boolean {
// 날씨 API 연동하여 비오는 날 할인 적용
return this.isRainyDay(context.bookingDate);
}
calculateDiscountedPrice(originalPrice: number, context: IPricingContext): number {
return Math.floor(originalPrice * 0.8); // 20% 할인
}
}
🔄 기존 코드와의 통합
MinuteSlotDto 확장:
EnhancedMinuteSlotDto
로 가격 계산 기능 추가- 기존 구조를 유지하면서 할인 정보 필드 확장
calculatePrice()
메서드로 실시간 가격 계산
DayAvailability와의 연동:
// 사용 예시
const pricingService = new TimeSlotPricingService();
const context: IPricingContext = {
currentSlot: { slotIndex: 36, hour: 6, minute: 0, basePrice: 5000 },
userHistory: { totalBookingCount: 25, consecutiveBookingDays: 5 },
currentSession: { selectedSlots: [36, 37, 38], totalDuration: 30 },
availableCoupons: [/* 쿠폰들 */],
bookingDate: new Date(),
currentTime: new Date()
};
const result = pricingService.calculateSlotPrice(context);
// 아침 할인(20%) + 단골 할인(10%) 중 더 유리한 할인 자동 선택
💡 주요 장점
- 단일 책임 원칙: 각 할인 전략이 독립적으로 동작
- 개방-폐쇄 원칙: 새로운 할인 전략 추가시 기존 코드 수정 불필요
- 유연한 할인 적용: 여러 할인 중 가장 유리한 것 자동 선택
- 컨텍스트 기반: 사용자 이력, 예약 세션, 시간대 등 종합 고려
- UI 친화적: 할인 정보를 구조화하여 프론트엔드 표시 용이
이 설계를 통해 복잡한 할인 로직을 체계적으로 관리하면서도, 새로운 할인 정책 추가나 기존 정책 수정이 용이한 확장 가능한 시스템을 구축할 수 있습니다.
// ═══════════════════════════════════════════════════════════════════════════
// 1. 할인 계산을 위한 컨텍스트 정보
// ═══════════════════════════════════════════════════════════════════════════
export interface IPricingContext {
/** 현재 계산 중인 타임슬롯 정보 */
readonly currentSlot: {
slotIndex: number;
hour: number;
minute: number;
basePrice: number;
};
/** 사용자 예약 이력 정보 */
readonly userHistory: {
totalBookingCount: number;
lastBookingDate?: Date;
consecutiveBookingDays: number;
};
/** 현재 예약 세션 정보 */
readonly currentSession: {
selectedSlots: number[]; // 선택된 슬롯 인덱스들
totalDuration: number; // 총 예약 시간(분)
};
/** 쿠폰 정보 */
readonly availableCoupons: ICoupon[];
/** 예약 날짜/시간 정보 */
readonly bookingDate: Date;
readonly currentTime: Date;
}
export interface ICoupon {
id: string;
type: 'percentage' | 'fixed' | 'time_based';
value: number;
minBookingAmount?: number;
validUntil: Date;
usageLimit?: number;
usedCount: number;
}
// ═══════════════════════════════════════════════════════════════════════════
// 2. 할인 전략 인터페이스
// ═══════════════════════════════════════════════════════════════════════════
export interface IDiscountStrategy {
/** 할인 전략의 고유 식별자 */
readonly strategyId: string;
/** 할인 전략의 우선순위 (낮을수록 먼저 적용) */
readonly priority: number;
/** 이 할인이 현재 컨텍스트에 적용 가능한지 확인 */
canApply(context: IPricingContext): boolean;
/** 할인된 가격 계산 */
calculateDiscountedPrice(originalPrice: number, context: IPricingContext): number;
/** 할인 정보 반환 (UI 표시용) */
getDiscountInfo(context: IPricingContext): IDiscountInfo;
}
export interface IDiscountInfo {
strategyId: string;
discountName: string;
discountAmount: number;
discountRate?: number;
description: string;
}
// ═══════════════════════════════════════════════════════════════════════════
// 3. 구체적인 할인 전략 구현들
// ═══════════════════════════════════════════════════════════════════════════
/**
* 횟수 할인: 예약 횟수에 따른 누적 할인
*/
export class BookingCountDiscountStrategy implements IDiscountStrategy {
readonly strategyId = 'booking_count_discount';
readonly priority = 1;
private readonly discountTiers = [
{ minCount: 10, discountRate: 0.05 }, // 10회 이상 5% 할인
{ minCount: 20, discountRate: 0.10 }, // 20회 이상 10% 할인
{ minCount: 50, discountRate: 0.15 }, // 50회 이상 15% 할인
];
canApply(context: IPricingContext): boolean {
return context.userHistory.totalBookingCount >= this.discountTiers[0].minCount;
}
calculateDiscountedPrice(originalPrice: number, context: IPricingContext): number {
const tier = this.discountTiers
.reverse()
.find(tier => context.userHistory.totalBookingCount >= tier.minCount);
if (!tier) return originalPrice;
return Math.floor(originalPrice * (1 - tier.discountRate));
}
getDiscountInfo(context: IPricingContext): IDiscountInfo {
const tier = this.discountTiers
.reverse()
.find(tier => context.userHistory.totalBookingCount >= tier.minCount);
return {
strategyId: this.strategyId,
discountName: '단골 할인',
discountAmount: Math.floor(context.currentSlot.basePrice * tier!.discountRate),
discountRate: tier!.discountRate,
description: `${context.userHistory.totalBookingCount}회 예약 달성 (${tier!.discountRate * 100}% 할인)`
};
}
}
/**
* 시간대 할인: 특정 시간대에 따른 할인
*/
export class TimeBasedDiscountStrategy implements IDiscountStrategy {
readonly strategyId = 'time_based_discount';
readonly priority = 2;
private readonly timeDiscounts = [
{ startHour: 6, endHour: 9, discountRate: 0.20, name: '아침 할인' },
{ startHour: 14, endHour: 17, discountRate: 0.15, name: '오후 할인' },
{ startHour: 21, endHour: 23, discountRate: 0.25, name: '늦은 시간 할인' },
];
canApply(context: IPricingContext): boolean {
return this.timeDiscounts.some(discount =>
context.currentSlot.hour >= discount.startHour &&
context.currentSlot.hour < discount.endHour
);
}
calculateDiscountedPrice(originalPrice: number, context: IPricingContext): number {
const discount = this.timeDiscounts.find(discount =>
context.currentSlot.hour >= discount.startHour &&
context.currentSlot.hour < discount.endHour
);
if (!discount) return originalPrice;
return Math.floor(originalPrice * (1 - discount.discountRate));
}
getDiscountInfo(context: IPricingContext): IDiscountInfo {
const discount = this.timeDiscounts.find(discount =>
context.currentSlot.hour >= discount.startHour &&
context.currentSlot.hour < discount.endHour
)!;
return {
strategyId: this.strategyId,
discountName: discount.name,
discountAmount: Math.floor(context.currentSlot.basePrice * discount.discountRate),
discountRate: discount.discountRate,
description: `${discount.startHour}시-${discount.endHour}시 시간대 할인`
};
}
}
/**
* 재결제 할인: 연속 예약일에 따른 할인
*/
export class ConsecutiveBookingDiscountStrategy implements IDiscountStrategy {
readonly strategyId = 'consecutive_booking_discount';
readonly priority = 3;
canApply(context: IPricingContext): boolean {
return context.userHistory.consecutiveBookingDays >= 3;
}
calculateDiscountedPrice(originalPrice: number, context: IPricingContext): number {
const days = context.userHistory.consecutiveBookingDays;
const discountRate = Math.min(days * 0.02, 0.20); // 최대 20% 할인
return Math.floor(originalPrice * (1 - discountRate));
}
getDiscountInfo(context: IPricingContext): IDiscountInfo {
const days = context.userHistory.consecutiveBookingDays;
const discountRate = Math.min(days * 0.02, 0.20);
return {
strategyId: this.strategyId,
discountName: '연속 예약 할인',
discountAmount: Math.floor(context.currentSlot.basePrice * discountRate),
discountRate: discountRate,
description: `${days}일 연속 예약 (${discountRate * 100}% 할인)`
};
}
}
/**
* 쿠폰 할인: 보유 쿠폰을 활용한 할인
*/
export class CouponDiscountStrategy implements IDiscountStrategy {
readonly strategyId = 'coupon_discount';
readonly priority = 4;
canApply(context: IPricingContext): boolean {
return context.availableCoupons.some(coupon =>
coupon.validUntil > context.currentTime &&
coupon.usedCount < (coupon.usageLimit ?? Infinity) &&
(coupon.minBookingAmount ?? 0) <= context.currentSlot.basePrice
);
}
calculateDiscountedPrice(originalPrice: number, context: IPricingContext): number {
const bestCoupon = this.getBestCoupon(originalPrice, context);
if (!bestCoupon) return originalPrice;
switch (bestCoupon.type) {
case 'percentage':
return Math.floor(originalPrice * (1 - bestCoupon.value / 100));
case 'fixed':
return Math.max(0, originalPrice - bestCoupon.value);
case 'time_based':
// 시간 기반 쿠폰 (예: 30분 무료)
const freeMinutes = bestCoupon.value;
const pricePerMinute = originalPrice / 10; // 10분 슬롯 기준
return Math.max(0, originalPrice - (freeMinutes * pricePerMinute));
default:
return originalPrice;
}
}
private getBestCoupon(originalPrice: number, context: IPricingContext): ICoupon | null {
const validCoupons = context.availableCoupons.filter(coupon =>
coupon.validUntil > context.currentTime &&
coupon.usedCount < (coupon.usageLimit ?? Infinity) &&
(coupon.minBookingAmount ?? 0) <= originalPrice
);
if (validCoupons.length === 0) return null;
// 가장 할인 효과가 큰 쿠폰 선택
return validCoupons.reduce((best, current) => {
const bestDiscount = this.calculateCouponDiscount(originalPrice, best);
const currentDiscount = this.calculateCouponDiscount(originalPrice, current);
return currentDiscount > bestDiscount ? current : best;
});
}
private calculateCouponDiscount(originalPrice: number, coupon: ICoupon): number {
switch (coupon.type) {
case 'percentage':
return originalPrice * (coupon.value / 100);
case 'fixed':
return Math.min(coupon.value, originalPrice);
case 'time_based':
const freeMinutes = coupon.value;
const pricePerMinute = originalPrice / 10;
return Math.min(freeMinutes * pricePerMinute, originalPrice);
default:
return 0;
}
}
getDiscountInfo(context: IPricingContext): IDiscountInfo {
const bestCoupon = this.getBestCoupon(context.currentSlot.basePrice, context)!;
const discountAmount = this.calculateCouponDiscount(context.currentSlot.basePrice, bestCoupon);
return {
strategyId: this.strategyId,
discountName: `쿠폰 할인 (${bestCoupon.id})`,
discountAmount,
description: `${bestCoupon.type} 쿠폰 적용`
};
}
}
/**
* 장시간 예약 할인: 한 번에 긴 시간 예약시 할인
*/
export class LongDurationDiscountStrategy implements IDiscountStrategy {
readonly strategyId = 'long_duration_discount';
readonly priority = 5;
private readonly durationTiers = [
{ minMinutes: 60, discountRate: 0.05 }, // 1시간 이상 5% 할인
{ minMinutes: 120, discountRate: 0.10 }, // 2시간 이상 10% 할인
{ minMinutes: 180, discountRate: 0.15 }, // 3시간 이상 15% 할인
];
canApply(context: IPricingContext): boolean {
return context.currentSession.totalDuration >= this.durationTiers[0].minMinutes;
}
calculateDiscountedPrice(originalPrice: number, context: IPricingContext): number {
const tier = this.durationTiers
.reverse()
.find(tier => context.currentSession.totalDuration >= tier.minMinutes);
if (!tier) return originalPrice;
return Math.floor(originalPrice * (1 - tier.discountRate));
}
getDiscountInfo(context: IPricingContext): IDiscountInfo {
const tier = this.durationTiers
.reverse()
.find(tier => context.currentSession.totalDuration >= tier.minMinutes)!;
return {
strategyId: this.strategyId,
discountName: '장시간 예약 할인',
discountAmount: Math.floor(context.currentSlot.basePrice * tier.discountRate),
discountRate: tier.discountRate,
description: `${context.currentSession.totalDuration}분 예약 (${tier.discountRate * 100}% 할인)`
};
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 4. 할인 계산 서비스 (Strategy Pattern의 Context)
// ═══════════════════════════════════════════════════════════════════════════
export class TimeSlotPricingService {
private readonly discountStrategies: IDiscountStrategy[] = [];
constructor() {
// 할인 전략들을 우선순위 순으로 등록
this.discountStrategies = [
new BookingCountDiscountStrategy(),
new TimeBasedDiscountStrategy(),
new ConsecutiveBookingDiscountStrategy(),
new CouponDiscountStrategy(),
new LongDurationDiscountStrategy(),
].sort((a, b) => a.priority - b.priority);
}
/**
* 특정 타임슬롯의 최종 가격을 계산합니다.
* 여러 할인이 적용 가능한 경우, 가장 유리한 할인만 적용합니다.
*/
calculateSlotPrice(context: IPricingContext): ISlotPricingResult {
const applicableDiscounts = this.discountStrategies
.filter(strategy => strategy.canApply(context))
.map(strategy => ({
strategy,
discountedPrice: strategy.calculateDiscountedPrice(context.currentSlot.basePrice, context),
discountInfo: strategy.getDiscountInfo(context)
}));
if (applicableDiscounts.length === 0) {
return {
originalPrice: context.currentSlot.basePrice,
finalPrice: context.currentSlot.basePrice,
appliedDiscount: null,
availableDiscounts: []
};
}
// 가장 할인 효과가 큰 전략 선택
const bestDiscount = applicableDiscounts.reduce((best, current) =>
current.discountedPrice < best.discountedPrice ? current : best
);
return {
originalPrice: context.currentSlot.basePrice,
finalPrice: bestDiscount.discountedPrice,
appliedDiscount: bestDiscount.discountInfo,
availableDiscounts: applicableDiscounts.map(d => d.discountInfo)
};
}
/**
* 전체 예약 세션의 총 가격을 계산합니다.
*/
calculateSessionTotalPrice(
baseSlotPrices: number[],
selectedSlotIndices: number[],
userContext: Omit<IPricingContext, 'currentSlot'>
): ISessionPricingResult {
const slotResults: ISlotPricingResult[] = [];
let totalOriginalPrice = 0;
let totalFinalPrice = 0;
selectedSlotIndices.forEach((slotIndex, index) => {
const slotTime = DayAvailability.slotIndexToTime(slotIndex);
const context: IPricingContext = {
...userContext,
currentSlot: {
slotIndex,
hour: slotTime.h,
minute: slotTime.m,
basePrice: baseSlotPrices[slotIndex]
}
};
const result = this.calculateSlotPrice(context);
slotResults.push(result);
totalOriginalPrice += result.originalPrice;
totalFinalPrice += result.finalPrice;
});
return {
slotResults,
totalOriginalPrice,
totalFinalPrice,
totalSavings: totalOriginalPrice - totalFinalPrice
};
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 5. 결과 타입 정의
// ═══════════════════════════════════════════════════════════════════════════
export interface ISlotPricingResult {
originalPrice: number;
finalPrice: number;
appliedDiscount: IDiscountInfo | null;
availableDiscounts: IDiscountInfo[];
}
export interface ISessionPricingResult {
slotResults: ISlotPricingResult[];
totalOriginalPrice: number;
totalFinalPrice: number;
totalSavings: number;
}
// ═══════════════════════════════════════════════════════════════════════════
// 6. MinuteSlotDto 확장 (기존 코드와의 통합)
// ═══════════════════════════════════════════════════════════════════════════
/**
* 기존 MinuteSlotDto를 가격 계산 기능과 함께 확장
*/
export class EnhancedMinuteSlotDto extends MinuteSlotDto {
@NumberProperty({ description: '할인 적용 후 최종 가격', optional: true })
finalPrice?: number;
@ObjectProperty({ description: '적용된 할인 정보', optional: true })
appliedDiscount?: IDiscountInfo;
@ArrayProperty({
type: Object,
description: '사용 가능한 모든 할인 정보',
optional: true
})
availableDiscounts?: IDiscountInfo[];
/**
* 가격 계산 서비스를 사용하여 할인 가격을 계산하고 설정
*/
calculatePrice(
pricingService: TimeSlotPricingService,
context: IPricingContext
): void {
if (!this.isAvailable || !this.price) return;
const result = pricingService.calculateSlotPrice({
...context,
currentSlot: {
...context.currentSlot,
basePrice: this.price
}
});
this.finalPrice = result.finalPrice;
this.appliedDiscount = result.appliedDiscount;
this.availableDiscounts = result.availableDiscounts;
}
}